Taeseong Blog

NextJS 다중 탭 환경에서 로그인/로그아웃 동기화 (멀티탭 세션 일관성)

2026-01-19

이슈 해결NextJS

멀티 탭 로그인/로그아웃 동기화, 어디까지 책임져야 할까

Next.js 프로젝트를 진행하면서 정말 골치 아팠던 게 하나 있었습니다. 바로 멀티 탭 인증 동기화 문제였죠. 사용자들은 당연하게 여러 탭을 열어두고 쓰는데, 한 탭에서 로그인하면 다른 탭도 자동으로 로그인되길 기대합니다. 하지만 막상 구현하려니 생각보다 훨씬 복잡한 문제들이 튀어나왔습니다.

문제: 각 탭의 고립성

실제로 우리 서비스에서 겪었던 상황들을 보면

  • 탭 A에서 로그인했는데 → 탭 B는 여전히 로그인 페이지 그대로
  • 탭 B에서 로그아웃했는데 → 탭 A는 계속 로그인 상태 유지
  • 탭 A에서 A 계정으로 로그인, 탭 B에서 B 계정 로그인 시도 → 새로고침하면 다시 A 계정으로 롤백되는 황당한 상황
  • 쿠키는 이미 만료됐는데, UI는 여전히 "Taeseong님 환영합니다" 표시

핵심 원인은 명확했습니다. 인증의 진짜 기준은 서버의 httpOnly 쿠키인데, 클라이언트 상태(Zustand)는 각 탭마다 완전히 독립적으로 존재하고 있었던 거죠. 그러니 당연히 꼬일 수밖에 없었습니다.

해결 원칙: 누가 진짜 주인인가

문제를 풀기 위해 먼저 원칙을 정했습니다.

  1. 인증의 단일 진실(Source of Truth)은 서버의 httpOnly 쿠키
  2. 클라이언트 상태는 "캐시"일 뿐, 언제든 버릴 수 있어야 함
  3. 멀티 탭에서는 상태 변경을 반드시 전파해야 함

즉, 로그인/로그아웃의 판단은 서버가 하고, 클라이언트는 그 상태를 각 탭에 동기화하는 역할만 합니다.

BroadcastChannel을 선택한 이유

멀티 탭 동기화 방법은 여러 가지가 있습니다. 서버 폴링, 쿠키 변화 감지, Service Worker, localStorage 이벤트 등등. 최종적으로 선택한 건 BroadcastChannel + localStorage 이벤트 폴백 조합이었습니다.

// BroadcastChannel 사용 예시
const channel = new BroadcastChannel('auth-sync');

// 탭 A에서 로그인 성공 시
channel.postMessage({ 
  type: 'LOGIN_SYNC', 
  user: { userId: 123, email: 'user@example.com' } 
});

// 다른 탭들이 즉시 수신 (탭 A 제외)
channel.addEventListener('message', (event) => {
  console.log('다른 탭에서 로그인:', event.data.user);
});

선택 이유는 간단했습니다.

  • 같은 origin의 탭 간 실시간 통신에 최적화되어 있음
  • 서버 부하 없음 (클라이언트끼리만 통신)
  • localStorage 이벤트를 폴백으로 두면 구형 브라우저도 커버 가능
  • 구현 난이도 대비 안정성이 우수함

그리고 각 탭을 구분하기 위해 sessionStorage에 고유 ID를 저장했습니다.

const getTabId = () => {
  let tabId = sessionStorage.getItem('auth-tab-id');
  if (!tabId) {
    tabId = crypto.randomUUID();
    sessionStorage.setItem('auth-tab-id', tabId);
  }
  return tabId;
};

이렇게 하면 메시지를 보낸 탭이 자신의 메시지를 다시 받는 걸 방지할 수 있습니다. 각 탭은 자기가 보낸 메시지의 sourceId를 체크해서 무시하는 방식이죠.

실제 구현: 로그인 흐름

1) 서버에서 쿠키 설정

로그인 요청은 서버 액션(login.action.ts)에서 처리합니다. Nest 서버와 Spring Boot 서버를 병렬로 호출하고, Nest 인증이 성공하면 다음 작업을 수행합니다.

// login.action.ts
export const loginAction = async (formData: FormData) => {
  const loginResponses = await login({ email, password });
  
  const nestSuccess = loginResponses.some(
    r => r.code === SUCCESS_CODE && !!r.data
  );
  
  if (nestSuccess) {
    // accessToken → httpOnly 쿠키
    // refresh_token 재설정
    redirect('/'); // 쿠키 설정 후 즉시 리다이렉트
  }
};

이때 설정되는 쿠키들은 모두 httpOnly로 설정됩니다. JavaScript로는 접근할 수 없어서 XSS 공격으로부터 안전하죠.

Set-Cookie: accessToken=eyJhbGci...; HttpOnly; Secure; SameSite=Lax
Set-Cookie: refresh_token=eyJyZWZy...; HttpOnly; Secure; SameSite=Lax

2) 로그인 후 화면인 after-login 레이아웃에서 user 구성

서버 컴포넌트인 레이아웃에서 쿠키의 accessToken을 디코딩해 사용자 정보를 얻습니다.

// app/(after-login)/layout.tsx
export default async function AfterLoginLayout({ children }) {
  const user = await getServerUserFromCookies(); // 쿠키 디코딩
  
  return (
    <>
      <UserHydrator user={user} />
      {children}
    </>
  );
}

getServerUserFromCookies는 단순히 쿠키에서 accessToken을 읽어서 디코딩하는 함수입니다. 서버 측에서만 실행되기 때문에 httpOnly 쿠키에 접근할 수 있죠.

3) UserHydrator가 동기화의 핵심

UserHydrator는 서버-클라이언트 상태를 동기화하는 핵심 컴포넌트입니다. 서버에서 받은 사용자 정보를 Zustand에 저장하고, 다른 탭에 로그인 이벤트를 전파합니다.

// UserHydrator.tsx
'use client';

export function UserHydrator({ user }) {
  const login = useUserStore(s => s.login);
  const clearUser = useUserStore(s => s.clearUser);
  const router = useRouter();
  
  // 초기 hydration
  useEffect(() => {
    if (user) {
      login(user); // Zustand에 저장
      publishLoginSync(user); // 다른 탭에 알림
    } else {
      logout(); // 쿠키 없으면 로그아웃
    }
  }, [user]);
  
  // 다른 탭의 이벤트 구독
  useEffect(() => {
    return subscribeAuthSync((message) => {
      if (message.type === 'LOGIN_SYNC' && message.user) {
        login(message.user);
        router.refresh(); // 서버 상태 다시 가져오기
      }
      
      if (message.type === 'LOGOUT_SYNC') {
        clearUser(); // 서버 호출 없이 클라이언트만 정리
        router.replace('/login');
      }
    });
  }, []);
  
  return null; // UI 없음
}

이 컴포넌트가 SSR/CSR 경계에서 작동하면서, 서버 기준 인증 상태를 클라이언트가 한 번 더 검증하는 구조가 됩니다.

로그아웃은 반대 방향으로

로그아웃은 로그인의 반대 방향으로 흐릅니다.

  1. UI에서 로그아웃 버튼 클릭
  2. userStore.logout() 실행
  3. /api/logout 호출 → 서버 쿠키 전체 삭제
  4. localStorage(user-storage) 제거
  5. 상태 초기화
  6. publishLogoutSync() 실행
// userStore.ts
logout: async () => {
  // 서버에 로그아웃 요청 (쿠키 삭제)
  try {
    await fetch('/api/logout', { method: 'POST' });
  } catch {
    // 서버 실패해도 클라이언트는 로그아웃 진행
  }
  
  // localStorage 삭제
  localStorage.removeItem('user-storage');
  
  // 상태 초기화
  set({ user: null });
  
  // 다른 탭에 알림
  publishLogoutSync();
}

다른 탭들은 UserHydrator에서 LOGOUT_SYNC를 받으면 즉시 clearUser()를 실행하고 /login으로 이동합니다.

if (message.type === 'LOGOUT_SYNC') {
  clearUser(); // publishLogoutSync 호출 안 함 (무한 루프 방지)
  router.replace('/login');
}

여기서 중요한 건 clearUser()publishLogoutSync()를 호출하지 않는다는 점입니다. 그래야 무한 루프를 방지할 수 있죠.

"이미 로그인됨" 중복 방지

또 다른 예외 케이스는 이미 로그인된 상태에서 다른 탭의 로그인 페이지에 다시 접근하는 경우입니다. 이건 서버와 클라이언트 양쪽에서 막았습니다.

서버 레벨: middleware에서 /login 접근 시 accessToken 유효성을 확인하고, 유효하면 /로 리다이렉트합니다.

클라이언트 레벨: 로그인 페이지에서 subscribeAuthSyncLOGIN_SYNC를 구독합니다. 이벤트를 받으면 isAlreadyLoggedIn = true로 설정하고, 이 상태에서 submit 시도 시 막습니다.

// login/page.tsx
const LoginPage = () => {
  const [isAlreadyLoggedIn, setIsAlreadyLoggedIn] = useState(false);
  const { showToast } = useToastMessage();
  const router = useRouter();
  
  // 다른 탭의 로그인 감지
  useEffect(() => {
    return subscribeAuthSync((message) => {
      if (message.type === 'LOGIN_SYNC') {
        setIsAlreadyLoggedIn(true);
        router.replace('/'); // 즉시 홈으로 이동
      }
      
      if (message.type === 'LOGOUT_SYNC') {
        setIsAlreadyLoggedIn(false);
      }
    });
  }, []);
  
  // 폼 제출 시 체크
  const handleSubmit = (e) => {
    if (isAlreadyLoggedIn) {
      e.preventDefault(); // 제출 막기
      showToast({ title: '이미 로그인되어 있습니다', type: 'info' });
      router.replace('/');
      return;
    }
    
    // 정상 진행
  };
  
  return (
    <form action={loginAction} onSubmit={handleSubmit}>
      {/* ... */}
    </form>
  );
};

결과적으로 "다른 탭에서 이미 로그인됨"을 즉시 인지할 수 있게 되었습니다.

authSync 유틸리티의 전체 구조

멀티 탭 동기화의 핵심 로직을 담은 authSync.ts의 주요 함수들을 보면 다음과 같습니다.

// src/shared/lib/authSync.ts

// 탭 고유 ID 생성
const getTabId = () => {
  let tabId = sessionStorage.getItem('auth-tab-id');
  
  if (!tabId) {
    tabId = typeof crypto !== 'undefined' && 'randomUUID' in crypto
      ? crypto.randomUUID() 
      : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
    
    sessionStorage.setItem('auth-tab-id', tabId);
  }
  
  return tabId;
};

// 로그인 이벤트 발행
export const publishLoginSync = (user: User) => {
  const message = {
    type: 'LOGIN_SYNC',
    user: user,
    ts: Date.now(),
    sourceId: getTabId()
  };
  
  // BroadcastChannel로 전송
  const channel = getChannel();
  if (channel) {
    channel.postMessage(message);
  }
  
  // localStorage에도 기록 (storage 이벤트 폴백용)
  try {
    localStorage.setItem('auth-sync-event', JSON.stringify(message));
  } catch {}
};

// 이벤트 구독
export const subscribeAuthSync = (
  onMessage: (message: AuthSyncMessage) => void
) => {
  const tabId = getTabId();
  
  const handleMessage = (message: AuthSyncMessage | null) => {
    if (!message) return;
    
    // 유효한 타입인지 확인
    if (message.type !== 'LOGIN_SYNC' && message.type !== 'LOGOUT_SYNC') {
      return;
    }
    
    // 자신이 보낸 메시지는 무시
    if (message.sourceId === tabId) {
      return;
    }
    
    onMessage(message);
  };
  
  // BroadcastChannel 리스너
  const channel = getChannel();
  const channelListener = (event: MessageEvent) => {
    handleMessage(event.data);
  };
  
  if (channel) {
    channel.addEventListener('message', channelListener);
  }
  
  // Storage 이벤트 리스너 (폴백)
  const storageListener = (event: StorageEvent) => {
    if (event.key !== 'auth-sync-event') return;
    if (!event.newValue) return;
    
    try {
      const parsed = JSON.parse(event.newValue);
      handleMessage(parsed);
    } catch {}
  };
  
  window.addEventListener('storage', storageListener);
  
  // Cleanup 함수 반환
  return () => {
    if (channel) {
      channel.removeEventListener('message', channelListener);
      channel.close();
    }
    window.removeEventListener('storage', storageListener);
  };
};

이 구조의 핵심은 BroadcastChannel을 메인으로 사용하되, storage 이벤트를 폴백으로 두어 호환성을 확보한다는 점입니다.

Zustand 스토어 구조

사용자 상태를 관리하는 Zustand 스토어는 persist 미들웨어를 사용해 localStorage에 자동으로 저장됩니다.

// src/domains/user/store/userStore.ts

export const useUserStore = create<UserState>()(
  persist(
    (set) => ({
      user: null,
      
      // 로그인 액션
      login: (userData: User) => {
        set({ user: userData });
        // persist 미들웨어가 자동으로 localStorage에 저장
      },
      
      // 로그아웃 액션 (서버 호출 포함)
      logout: async () => {
        try {
          await fetch('/api/logout', { method: 'POST' });
        } catch {}
        
        localStorage.removeItem('user-storage');
        set({ user: null });
        publishLogoutSync();
      },
      
      // 클라이언트만 정리 (다른 탭의 로그아웃 수신 시)
      clearUser: () => {
        localStorage.removeItem('user-storage');
        set({ user: null });
        // publishLogoutSync 호출 안 함 (무한 루프 방지)
      },
    }),
    {
      name: 'user-storage',
      storage: createJSONStorage(() => localStorage),
    }
  )
);

중요한 건 이 클라이언트 상태가 단순한 "캐시"라는 점입니다. 실제 인증은 서버의 httpOnly 쿠키로만 수행되고, localStorage의 정보는 UI 표시 용도일 뿐입니다.

로그아웃 API 구현

서버의 /api/logout 엔드포인트는 모든 인증 쿠키를 삭제합니다.

// src/app/api/logout/route.ts
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';

export async function POST() {
  const cookieStore = await cookies();
  
  // 만료 시간을 과거로 설정해 브라우저가 쿠키 삭제하도록 함
  const cookieOptions = {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax' as const,
    path: '/',
    expires: new Date(0), // 1970-01-01
  };
  
  // 모든 인증 쿠키 삭제
  cookieStore.set('accessToken', '', cookieOptions);
  cookieStore.set('refresh_token', '', cookieOptions);
  cookieStore.set('loginKey', '', cookieOptions);
  cookieStore.set('loginType', '', cookieOptions);
  cookieStore.set('loginId', '', cookieOptions);
  
  return NextResponse.json({ message: 'Logged out' });
}

쿠키를 삭제하는 방법은 만료 시간을 과거로 설정하는 것입니다. 브라우저가 알아서 쿠키를 지워주죠.

결과적으로 얻은 것

이 구조를 도입한 뒤 달라진 점들입니다.

  • 탭 간 로그인/로그아웃 즉시 동기화
  • 계정 꼬임 현상 제거
  • 인증 책임이 서버에 명확히 귀속
  • 클라이언트 상태는 언제든 재구성 가능

특히 UserHydrator를 SSR/CSR 경계에 둔 것이 큰 역할을 했습니다. 서버 기준 인증 상태를 클라이언트가 한 번 더 검증하는 구조가 되었기 때문이죠.

실제 동작을 보면 이렇습니다.

// 시나리오: 3개 탭에서 로그인 테스트

초기 상태:
탭 A, B, C 모두 로그인 페이지

탭 A에서 로그인 성공
  ↓
publishLoginSync 실행
  ↓
탭 B, C가 즉시 수신 (100ms 이내)
  ↓
모든 탭이 홈(/)으로 리다이렉트
  ↓
동일한 사용자 정보 표시

마치며

"멀티 탭 인증 문제는 상태 관리 문제가 아니라, 책임 분리 문제다."